Elastic Security Labs

WinVisor: un emulador basado en hipervisor para ejecutables de modo usuario de Windows x64

Un emulador basado en hipervisor de prueba de concepto para binarios de Windows x64

25 min de lecturaPerspectivas
WinVisor: un emulador basado en hipervisor para ejecutables en modo usuario de Windows x64

Fondo

En Windows 10 (versión RS4), Microsoft introdujo la API de la plataforma de hipervisor de Windows (WHP). Esta API expone la funcionalidad de hipervisor integrada de Microsoft a las aplicaciones de Windows en modo usuario. En 2024, el autor empleó esta API para crear un proyecto personal: un emulador MS-DOS de 16 bits llamado DOSVisor. Como se menciona en las notas de la versión, siempre hubo planes para llevar este concepto más allá y usarlo para emular aplicaciones de Windows. Elastic ofrece una semana de investigación (ON Week) dos veces al año para que el personal trabaje en proyectos personales, lo que proporciona una gran oportunidad para comenzar a trabajar en este proyecto. Este proyecto se llamará (sin mucha imaginación) WinVisor, inspirado en su predecesor DOSVisor.

Los hipervisores proporcionan virtualización a nivel de hardware, eliminando la necesidad de emular la CPU a través del software. Esto garantiza que las instrucciones se ejecuten exactamente como lo harían en una CPU física, mientras que los emuladores basados en software a menudo se comportan de manera inconsistente en casos extremos.

Este proyecto tiene como objetivo construir un entorno virtual para ejecutar binarios de Windows x64, permitiendo registrar (o enganchar) llamadas al sistema y habilitando la introspección de memoria. El objetivo de este proyecto no es construir un entorno sandbox completo y seguro: de manera predeterminada, todas las llamadas al sistema simplemente se registrarán y reenviarán directamente al host. En su forma inicial, será trivial que el código que se ejecuta dentro del invitado virtualizado "escape" al host. Proteger de forma segura un espacio protegido es una tarea difícil y está fuera del alcance de este proyecto. Las limitaciones se describirán con más detalle al final del artículo.

A pesar de estar disponible durante 6 años (al momento de escribir este artículo), parece que la API de WHP no se empleó en muchos proyectos públicos aparte de bases de código complejas como QEMU y VirtualBox. Otro proyecto notable es Simpleator de Alex Ionescu, un emulador de modo de usuario de Windows liviano que también emplea la API WHP. Este proyecto tiene muchos de los mismos objetivos que WinVisor, aunque el enfoque para su implementación es bastante diferente. El proyecto WinVisor tiene como objetivo automatizar tanto como sea posible y soportar ejecutables simples (por ejemplo, ping.exe) universalmente listo para usar.

Este artículo cubrirá el diseño general del proyecto, algunos de los problemas que se encontraron y cómo se solucionaron. Algunas características estarán limitadas debido a restricciones de tiempo de desarrollo, pero el producto final será al menos una prueba de concepto utilizable. Al final del artículo se proporcionarán enlaces al código fuente y a los binarios alojados en GitHub.

Conceptos básicos del hipervisor

Los hipervisores funcionan con extensiones VT-x (Intel) y AMD-V (AMD). Estos marcos asistidos por hardware permiten la virtualización al permitir que una o más máquinas virtuales se ejecuten en una sola CPU física. Estas extensiones emplean conjuntos de instrucciones diferentes y, por lo tanto, no son inherentemente compatibles entre sí; se debe escribir código separado para cada una.

Internamente, Hyper-V emplea hvix64.exe para compatibilidad con Intel y hvax64.exe para compatibilidad con AMD. La API WHP de Microsoft abstrae estas diferencias de hardware, lo que permite que las aplicaciones creen y gestionen particiones virtuales independientemente del tipo de CPU subyacente. Para simplificar, la siguiente explicación se centrará únicamente en VT-x.

VT-x agrega un conjunto adicional de instrucciones conocido como VMX (Extensiones de máquina virtual), que contiene instrucciones como VMLAUNCH, que inicia la ejecución de una VM por primera vez, y VMRESUME, que vuelve a ingresar a la VM luego de una salida de la VM. Se produce una salida de VM cuando el invitado activa determinadas condiciones, como instrucciones específicas, acceso al puerto de E/S, fallas de página y otras excepciones.

En el centro de VMX se encuentra la Estructura de control de máquina virtual (VMCS), una estructura de datos por máquina virtual que almacena el estado de los contextos del huésped y del host, así como información sobre el entorno de ejecución. El VMCS contiene campos que definen el estado del procesador, configuraciones de control y condiciones opcionales que activan transiciones del invitado al host. Los campos VMCS se pueden leer o escribir empleando las instrucciones VMREAD y VMWRITE .

Durante una salida de VM, el procesador almacena el estado de invitado en el VMCS y vuelve al estado de host para la intervención del hipervisor.

Descripción general de WinVisor

Este proyecto aprovecha la naturaleza de alto nivel de la API de WHP. La API expone la funcionalidad del hipervisor al modo de usuario y permite que las aplicaciones mapeen la memoria virtual del proceso host directamente a la memoria física del invitado.

La CPU virtual opera casi exclusivamente en CPL3 (modo de usuario), a excepción de un pequeño cargador de arranque que se ejecuta en CPL0 (modo kernel) para inicializar el estado de la CPU antes de la ejecución. Esto se describirá con más detalle en la sección CPU virtual.

Para crear espacio de memoria para un entorno invitado emulado es necesario mapear el ejecutable de destino y todas las dependencias de DLL, seguido por el llenado de otras estructuras de datos internas como el Bloque de entorno de proceso (PEB), el Bloque de entorno de subprocesos (TEB), KUSER_SHARED_DATA, etc.

Mapear las dependencias de EXE y DLL es sencillo, pero mantener con precisión las estructuras internas, como el PEB, es una tarea más compleja. Estas estructuras son grandes, en su mayoría no están documentadas y su contenido puede variar entre versiones de Windows. Sería relativamente sencillo completar un conjunto minimalista de campos para ejecutar una aplicación simple del tipo "Hola Mundo", pero se debería adoptar un enfoque mejorado para proporcionar una buena compatibilidad.

En lugar de crear manualmente un entorno virtual, WinVisor lanza una instancia suspendida del proceso de destino y clona todo el espacio de direcciones en el invitado. Los directorios de datos de la tabla de direcciones de importación (IAT) y del almacenamiento local de subprocesos (TLS) se eliminan temporalmente de los encabezados PE en la memoria para detener la carga de las dependencias de DLL y para evitar que las devoluciones de llamadas TLS se ejecuten antes de llegar al punto de entrada. Luego se reanuda el proceso, lo que permite que continúe la inicialización habitual del proceso (LdrpInitializeProcess) hasta que llega al punto de entrada del ejecutable de destino, momento en el que el hipervisor se inicia y toma el control. Esto significa básicamente que Windows hizo todo el trabajo duro por nosotros y ahora tenemos un espacio de direcciones de modo de usuario previamente completado para el ejecutable de destino, listo para ejecutar.

Luego, se crea un nuevo hilo en estado suspendido, cuya dirección de inicio apunta a la dirección de una función de cargador personalizada. Esta función rellena el IAT, ejecuta devoluciones de llamadas TLS y, finalmente, ejecuta el punto de entrada original de la aplicación de destino. Esto esencialmente simula lo que haría el hilo principal si el proceso se ejecutara de forma nativa. Luego, el contexto de este hilo se "clona" en la CPU virtual y la ejecución comienza bajo el control del hipervisor.

La memoria se pagina en el invitado según sea necesario y las llamadas al sistema se interceptan, registran y reenvían al sistema operativo host hasta que el proceso de destino virtualizado sale.

Como la API de WHP solo permite que la memoria del proceso actual se asigne al invitado, la lógica del hipervisor principal se encapsula dentro de una DLL que se inyecta en el proceso de destino.

CPU virtual

La API de WHP proporciona un envoltorio "amigable" alrededor de la funcionalidad VMX descrita anteriormente, lo que significa que los pasos habituales, como completar manualmente el VMCS antes de ejecutar VMLAUNCH, ya no son necesarios. También expone la funcionalidad al modo de usuario, lo que significa que no se requiere un controlador personalizado. Sin embargo, la CPU virtual aún debe inicializar adecuadamente a través de WHP antes de ejecutar el código de destino. A continuación se describirán los aspectos importantes.

Registros de control

Solo los registros de control CR0, CR3 y CR4 son relevantes para este proyecto. CR0 y CR4 se emplean para habilitar opciones de configuración de CPU, como modo protegido, paginación y PAE. CR3 contiene la dirección física de la tabla de paginación PML4 , que se describirá con más detalle en la sección Paginación de memoria.

Registros específicos del modelo

Los registros específicos del modelo (MSR) también deben inicializar para garantizar el funcionamiento correcto de la CPU virtual. MSR_EFER contiene indicadores para funciones extendidas, como habilitar el modo largo (64 bits) e instrucciones SYSCALL . MSR_LSTAR contiene la dirección del controlador de llamadas al sistema y MSR_STAR contiene los selectores de segmento para la transición a CPL0 (y de regreso a CPL3) durante las llamadas al sistema. MSR_KERNEL_GS_BASE contiene la dirección base de sombra del selector GS .

Tabla de descriptores globales

La Tabla de Descriptores Globales (GDT) define los descriptores de segmento, que esencialmente describen regiones de memoria y sus propiedades para su uso en modo protegido.

En el modo largo, el GDT tiene un uso limitado y es en gran parte una reliquia del pasado: x64 siempre opera en un modo de memoria plana, lo que significa que todos los selectores están basados en 0. Las únicas excepciones a esto son los registros FS y GS , que se emplean para fines específicos del hilo. Incluso en esos casos, sus direcciones base no están definidas por el GDT. En su lugar, se emplean MSR (como MSR_KERNEL_GS_BASE descrito anteriormente) para almacenar la dirección base.

A pesar de esta obsolescencia, el GDT sigue siendo una parte importante del modelo x64. Por ejemplo, el nivel de privilegio actual está definido por el selector CS (segmento de código).

Segmento de estado de tarea

En el modo largo, el segmento de estado de tarea (TSS) se emplea simplemente para cargar el puntero de la pila al pasar de un nivel de privilegio inferior a uno superior. Como este emulador funciona casi exclusivamente en CPL3, a excepción del cargador de arranque inicial y los controladores de interrupciones, solo se asigna una única página para la pila CPL0. El TSS se almacena como una entrada de sistema especial dentro del GDT y ocupa dos ranuras.

Tabla de descriptores de interrupciones

La tabla de descriptores de interrupciones (IDT) contiene información sobre cada tipo de interrupción, como las direcciones de los controladores. Esto se describirá con más detalle en la sección Manejo de interrupciones.

Cargador de arranque

La mayoría de los campos de CPU mencionados anteriormente se pueden inicializar mediante funciones envolventes de WHP, pero el soporte para ciertos campos (por ejemplo, XCR0) sólo llegó en versiones posteriores de la API WHP (Windows 10 RS5). Para completar, el proyecto incluye un pequeño “gestor de arranque”, que se ejecuta en CPL0 al iniciar e inicializa manualmente las partes finales de la CPU antes de ejecutar el código de destino. A diferencia de una CPU física, que comenzaría en modo real de 16 bits, la CPU virtual ya fue inicializada para ejecutar en modo largo (64 bits), lo que hace que el proceso de arranque sea un poco más sencillo.

El gestor de arranque realiza los siguientes pasos:

  1. Cargue el GDT empleando la instrucción LGDT . El operando de origen de esta instrucción especifica un bloque de memoria de 10 bytes que contiene la dirección base y el límite (tamaño) de la tabla que se completó anteriormente.

  2. Cargue el IDT empleando la instrucción LIDT . El operando de origen de esta instrucción emplea el mismo formato que LGDT descrito anteriormente.

  3. Establezca el índice del selector TSS en el registro de tareas empleando la instrucción LTR . Como se mencionó anteriormente, el descriptor TSS existe como una entrada especial dentro del GDT (en 0x40 en este caso).

  4. El registro XCR0 se puede configurar mediante la instrucción XSETBV . Este es un registro de control adicional que se emplea para funciones opcionales como AVX. El proceso nativo ejecuta XGETBV para obtener el valor del host, que luego se copia en el invitado a través de XSETBV en el cargador de arranque.

Este es un paso importante porque las dependencias DLL que ya se cargaron pueden haber establecido indicadores globales durante su proceso de inicialización. Por ejemplo, ucrtbase.dll verifica si la CPU admite AVX a través de la instrucción CPUID al iniciar y, si es así, establece un indicador global para permitir que el CRT use instrucciones AVX por razones de optimización. Si la CPU virtual intenta ejecutar estas instrucciones AVX sin habilitarlas explícitamente en XCR0 primero, se generará una excepción de instrucción no definida.

  1. Actualice manualmente los selectores de segmentos de datos DS, ES y GS a sus equivalentes CPL3 (0x2B). Ejecute la instrucción SWAPGS para cargar la dirección base TEB desde MSR_KERNEL_GS_BASE.

  2. Por último, emplee la instrucción SYSRET para realizar la transición a CPL3. Antes de la instrucción SYSRET , RCX se establece en una dirección de marcador de posición (punto de entrada CPL3) y R11 se establece en el valor RFLAGS CPL3 inicial (0x202). La instrucción SYSRET cambia automáticamente los selectores de segmento CS y SS a sus equivalentes CPL3 de MSR_STAR.

Cuando se ejecuta la instrucción SYSRET , se generará un error de página debido a la dirección de marcador de posición no válida en RIP. El emulador detectará este error de página y lo reconocerá como una dirección “especial”. Luego, los valores iniciales del registro CPL3 se copiarán en la CPU virtual, RIP se actualizará para apuntar a una función de cargador de modo de usuario personalizada y se reanudará la ejecución. Esta función carga todas las dependencias de DLL para el ejecutable de destino, llena la tabla IAT, ejecuta devoluciones de llamadas TLS y luego ejecuta el punto de entrada original. La tabla de importación y las devoluciones de llamadas TLS se manejan en esta etapa, en lugar de antes, para garantizar que su código se ejecute dentro del entorno virtualizado.

Paginación de memoria

Toda gestión de memoria del invitado debe realizar manualmente. Esto significa que se debe completar y mantener una tabla de paginación, lo que permite que la CPU virtual traduzca una dirección virtual a una dirección física.

Traducción de direcciones virtuales

Para aquellos que no están familiarizados con la paginación en x64, la tabla de paginación tiene cuatro niveles: PML4, PDPT, PD y PT. Para cualquier dirección virtual dada, la CPU recorre cada capa de la tabla hasta llegar finalmente a la dirección física de destino. Las CPU modernas también admiten paginación de 5 niveles (en caso de que los 256 TB de memoria direccionable que ofrece la paginación de 4 niveles no sean suficientes), pero esto es irrelevante para los propósitos de este proyecto.

La siguiente imagen ilustra el formato de una dirección virtual de muestra:

Empleando el ejemplo anterior, la CPU calcularía la página física correspondiente a la dirección virtual 0x7FFB7D030D10 a través de las siguientes entradas de la tabla: PML4[0xFF] -> PDPT[0x1ED] -> PD[0x1E8] -> PT[0x30]. Finalmente, se agregará el desplazamiento (0xD10) a esta página física para calcular la dirección exacta.

Los bits 48 - 63 dentro de una dirección virtual no se emplean en la paginación de 4 niveles y esencialmente se extienden en signo para que coincidan con el bit 47.

El registro de control CR3 contiene la dirección física de la tabla base PML4 . Cuando la paginación está habilitada (obligatorio en el modo largo), todas las demás direcciones dentro del contexto de la CPU hacen referencia a direcciones virtuales.

Fallos de página

Cuando el invitado intenta acceder a la memoria, la CPU virtual generará una excepción de error de página si la página aplicar aún no está presente en la tabla de paginación. Esto activará un evento de salida de VM y pasará el control nuevamente al host. Cuando esto ocurre, el registro de control CR2 contiene la dirección virtual aplicar, aunque la API WHP ya proporciona este valor dentro de los datos de contexto de salida de la VM. Luego, el host puede mapear la página aplicar a la memoria (si es posible) y reanudar la ejecución o generar un error si la dirección de destino no es válida.

Duplicación de memoria de host/invitado

Como se mencionó anteriormente, el emulador crea un proceso secundario y toda la memoria virtual dentro de ese proceso se asignará directamente al invitado empleando el mismo diseño de direcciones. La API de la plataforma Hypervisor nos permite mapear la memoria virtual del proceso del modo de usuario del host directamente a la memoria física del invitado. Luego, la tabla de paginación asignará direcciones virtuales a las páginas físicas correspondientes.

En lugar de mapear todo el espacio de direcciones del proceso por adelantado, se asigna una cantidad fija de páginas físicas para el invitado. El emulador contiene un administrador de memoria muy básico y las páginas se asignan "a pedido". Cuando ocurre un error de página, se paginará la página aplicar y se reanudará la ejecución. Si todos los espacios de página están llenos, se intercambia la entrada más antigua para dejar lugar a la nueva.

Además de emplear un número fijo de páginas asignadas actualmente, el emulador también emplea una tabla de páginas de tamaño fijo. El tamaño de la tabla de páginas se determina calculando el número máximo posible de tablas para la cantidad de entradas de página asignadas. Este modelo da como resultado un diseño de memoria física simple y consistente, pero a costa de la eficiencia. De hecho, las tablas de paginación ocupan más espacio que las entradas de página reales.

Hay una única tabla PML4 y, en el peor de los casos, cada entrada de página asignada hará referencia a tablas PDPT/PD/PT únicas. Como cada tabla tiene 4096 bytes, el tamaño total de la tabla de páginas se puede calcular empleando la siguiente fórmula:

PAGE_TABLE_SIZE = 4096 + (MAXIMUM_MAPPED_PAGES * 4096 * 3)

De forma predeterminada, el emulador permite que se asignen 256 páginas a la vez (1024KB en total). Usando la fórmula anterior, podemos calcular que esto requerirá 3076KB para la tabla de paginación, como se ilustra a continuación:

En la práctica, muchas de las entradas de la tabla de páginas se compartirán y gran parte del espacio asignado para las tablas de paginación permanecerá sin emplear. Sin embargo, como este emulador funciona bien incluso con una pequeña cantidad de páginas, este nivel de sobrecarga no es una preocupación importante.

La CPU mantiene un caché a nivel de hardware para la tabla de paginación, conocido como Translation Lookaside Buffer (TLB). Al traducir una dirección virtual a una dirección física, la CPU primero verificará la TLB. Si no se encuentra una entrada coincidente en la memoria caché (lo que se conoce como “error de TLB”), se leerán las tablas de paginación en su lugar. Por este motivo, es importante vaciar la caché TLB cada vez que se reconstruyeron las tablas de paginación para evitar que se desincroniza. La forma más sencilla de limpiar todo el TLB es restablecer el valor del registro CR3 .

Manejo de llamadas al sistema

A medida que se correr el programa de destino, cualquier llamada del sistema que ocurra dentro del invitado debe ser manejada por el host. Este emulador maneja tanto instrucciones SYSCALL como llamadas al sistema heredadas (basadas en interrupciones). SYSENTER no se emplea en modo largo y, por lo tanto, no es compatible con WinVisor.

Llamada al sistema rápida (SYSCALL)

Cuando se ejecuta una instrucción SYSCALL , la CPU pasa a CPL0 y carga RIP desde MSR_LSTAR. En el kernel de Windows, esto apuntaría a KiSystemCall64. Las instrucciones SYSCALL no activarán inherentemente un evento de salida de VM, pero el emulador establece MSR_LSTAR en una dirección de marcador de posición reservada: 0xFFFF800000000000 en este caso. Cuando se ejecuta una instrucción SYSCALL , se generará un error de página cuando RIP se configure en esta dirección y se podrá interceptar la llamada. Este marcador de posición es una dirección de kernel en Windows y no causará ningún conflicto con el espacio de direcciones del modo de usuario.

A diferencia de las llamadas al sistema heredadas, la instrucción SYSCALL no intercambia el valor RSP durante la transición a CPL0, por lo que el puntero de pila del modo usuario se puede recuperar directamente desde RSP.

Llamadas al sistema heredadas (INT 2E)

Las llamadas al sistema basadas en interrupciones heredadas son más lentas y tienen más sobrecarga que la instrucción SYSCALL , pero a pesar de esto, Windows aún las admite. Como el emulador ya contiene un marco para manejar interrupciones, agregar soporte para llamadas al sistema heredadas es muy simple. Cuando se detecta una interrupción de llamada al sistema heredada, se puede reenviar al controlador de llamada al sistema “común” luego de algunas traducciones menores, específicamente, recuperar el valor del modo de usuario RSP almacenado de la pila CPL0.

Reenvío de llamadas al sistema

Después de que el emulador crea el "hilo principal" cuyo contexto se clona en la CPU virtual, este hilo nativo se reutiliza como proxy para reenviar llamadas al sistema al host. Reutilizar el mismo hilo mantiene la consistencia del TEB y cualquier estado del kernel entre el invitado y el host. Win32k, en individuo, depende de muchos estados específicos de cada subproceso, que deberían reflejar en el emulador.

Cuando se produce una llamada al sistema, ya sea por una instrucción SYSCALL o una interrupción heredada, el emulador la intercepta y la transfiere a una función de controlador universal. El número de llamada al sistema se almacena en el registro RAX y los primeros cuatro valores de parámetros se almacenan en R10, RDX, R8 y R9, respectivamente. R10 se emplea para el primer parámetro en lugar del registro RCX habitual porque la instrucción SYSCALL sobreescribir RCX con la dirección de retorno. El controlador de llamadas al sistema heredado de Windows (KiSystemService) también usa R10 por compatibilidad, por lo que no necesita manejar de manera diferente en el emulador. Los parámetros restantes se recuperan de la pila.

No sabemos la cantidad exacta de parámetros esperados para cualquier número de llamada al sistema determinado, pero afortunadamente, esto no importa. Simplemente podemos usar una cantidad fija y, siempre que el número de parámetros suministrados sea mayor o igual al número real, la llamada al sistema funcionará correctamente. Se creará dinámicamente un trozo de ensamblaje simple, que completará todos los parámetros, ejecutará la llamada al sistema de destino y regresará limpiamente.

Las pruebas mostraron que el número máximo de parámetros empleados actualmente por las llamadas al sistema de Windows es 17 (NtAccessCheckByTypeResultListAndAuditAlarmByHandle, NtCreateTokenEx y NtUserCreateWindowEx). WinVisor emplea 32 como el número máximo de parámetros para permitir una posible expansión futura.

Luego de ejecutar la llamada al sistema en el host, el valor de retorno se copia a RAX en el invitado. Luego, RIP se transfiere a una instrucción SYSRET (o IRETQ para llamadas al sistema heredadas) antes de reanudar la CPU virtual para una transición sin problemas al modo de usuario.

Registro de llamadas al sistema

De forma predeterminada, el emulador simplemente reenvía las llamadas al sistema del invitado al host y las registra en la consola. Sin embargo, son necesarios algunos pasos adicionales para convertir las llamadas al sistema sin procesar en un formato legible.

El primer paso es convertir el número de llamada al sistema en un nombre. Los números de llamada al sistema se componen de varias partes: los bits 12 - 13 contienen el índice de la tabla de servicio del sistema (0 para ntoskrnl, 1 para win32k) y los bits 0 - 11 contienen el índice de llamada al sistema dentro de la tabla. Esta información nos permite realizar una búsqueda inversa dentro del módulo de modo usuario correspondiente (ntdll / win32u) para resolver el nombre de la llamada al sistema original.

El siguiente paso es determinar la cantidad de valores de parámetros que se mostrarán para cada llamada al sistema. Como se mencionó anteriormente, el emulador pasa 32 valores de parámetros a cada llamada al sistema, incluso si la mayoría de ellos no se emplean. Sin embargo, registrar todos los valores 32 para cada llamada al sistema no sería ideal por razones de legibilidad. Por ejemplo, una simple llamada NtClose(0x100) se imprimiría como NtClose(0x100, xxx, xxx, xxx, xxx, xxx, xxx, xxx, xxx, ...). Como se mencionó anteriormente, no existe una manera sencilla de determinar automáticamente la cantidad exacta de parámetros para cada llamada al sistema, pero hay un truco que podemos usar para estimarlo con alta precisión.

Este truco se basa en las bibliotecas del sistema de 32 bits empleadas por WoW64. Estas bibliotecas emplean la convención de llamada stdcall, lo que significa que el llamador inserta todos los parámetros en la pila y el llamado los limpia internamente antes de regresar. Por el contrario, el código x64 nativo coloca los primeros 4 parámetros en registros y el llamador es responsable de gestionar la pila.

Por ejemplo, la función NtClose en la versión WoW64 de ntdll.dll termina con la instrucción RET 4 . Esto extrae 4 bytes adicionales de la pila luego de la dirección de retorno, lo que implica que la función toma un parámetro. Si la función emplea RET 8, esto sugeriría que toma 2 parámetros, y así sucesivamente.

Aunque el emulador se ejecuta como un proceso de 64 bits, aún podemos cargar las copias de 32 bits de ntdll.dll y win32u.dll en la memoria, ya sea manualmente o mapeadas usando SEC_IMAGE. Se debe escribir una versión personalizada de GetProcAddress para resolver las direcciones de exportación de WoW64, pero esta es una tarea trivial. Desde aquí, podemos encontrar automáticamente la exportación WoW64 correspondiente para cada llamada al sistema, buscar la instrucción RET para calcular la cantidad de parámetros y almacenar el valor en una tabla de búsqueda.

Este método no es perfecto y hay varias formas en las que podría fallar:

  • Una pequeña cantidad de llamadas al sistema nativas no existen en WoW64, como NtUserSetWindowLongPtr.
  • Si una función de 32 bits contiene un parámetro de 64 bits, se dividirá internamente en 2 parámetros de 32 bits, mientras que la función de 64 bits correspondiente solo requeriría un único parámetro para el mismo valor.
  • Las funciones del stub de llamada al sistema de WoW64 dentro de Windows podrían cambiar de tal manera que provoque que la búsqueda de instrucciones RET existente falle.

A pesar de estos inconvenientes, los resultados serán precisos para la gran mayoría de las llamadas al sistema sin tener que depender de valores codificados. Además, estos valores solo se emplean para fines de registro y no afectarán a nada más, por lo que se aceptan inexactitudes menores en este contexto. Si se detecta una falla, volverá a mostrar el número máximo de valores de parámetros.

Enganche de llamadas al sistema

Si este proyecto se empleara con fines de sandbox, reenviar ciegamente todas las llamadas al sistema al host sería indeseable por razones obvias. El emulador contiene un marco que permite conectar fácilmente llamadas al sistema específicas si es necesario.

De manera predeterminada, solo NtTerminateThread y NtTerminateProcess están conectados para detectar la salida del proceso invitado.

Manejo de interrupciones

Las interrupciones se definen mediante el IDT, que se completa antes de que comience la ejecución de la CPU virtual. Cuando se produce una interrupción, el estado actual de la CPU se envía a la pila CPL0 (SS, RSP, RFLAGS, CS, RIP) y RIP se establece en la función del controlador de destino.

Al igual que con MSR_LSTAR para el controlador SYSCALL, el emulador rellena todas las direcciones del controlador de interrupciones con valores de marcador de posición (0xFFFFA00000000000 - 0xFFFFA000000000FF). Cuando ocurre una interrupción, se producirá un fallo de página dentro de este rango, que podemos detectar. El índice de interrupción se puede extraer de los 8 bits más bajos de la dirección de destino (por ejemplo, 0xFFFFA00000000003 es INT 3) y el host puede manejarlo según sea necesario.

Actualmente, el emulador solo maneja INT 1 (paso único), INT 3 (punto de interrupción) y INT 2E (llamada al sistema heredada). Si se detecta cualquier otra interrupción, el emulador saldrá con un error.

Cuando se manejó una interrupción, RIP se transfiere a una instrucción IRETQ , que regresa al modo de usuario limpiamente. Algunos tipos de interrupciones introducen un valor de "código de error" adicional en la pila: si este es el caso, se debe eliminar antes de la instrucción IRETQ para evitar la corrupción de la pila. El marco del controlador de interrupciones dentro de este emulador contiene un indicador opcional para manejar esto de manera transparente.

Error de página compartida del hipervisor

Windows 10 introdujo un nuevo tipo de página compartida que se encuentra cerca de KUSER_SHARED_DATA. Esta página es empleada por funciones relacionadas con el tiempo, como RtlQueryPerformanceCounter y RtlGetMultiTimePrecise.

La dirección exacta de esta página se puede recuperar con NtQuerySystemInformation, empleando la clase de información SystemHypervisorSharedPageInformation . La función LdrpInitializeProcess almacena la dirección de esta página en una variable global (RtlpHypervisorSharedUserVa) durante el inicio del proceso.

La API de WHP parece contener un error que hace que la función WHvRunVirtualProcessor quede atrapada en un bucle infinito si esta página compartida está asignada al invitado y la CPU virtual intenta leer desde ella.

Las limitaciones de tiempo limitaron la capacidad de investigar esto a fondo; sin embargo, se implementó una solución alternativa simple. El emulador parchea la función NtQuerySystemInformation dentro del proceso de destino y lo obliga a devolver STATUS_INVALID_INFO_CLASS para SystemHypervisorSharedPageInformation solicitudes. Esto hace que el código ntdll recurra a los métodos tradicionales.

Demostraciones

A continuación, se muestran algunos ejemplos de ejecutables comunes de Windows que se emulan en este entorno virtualizado:

Limitaciones

El emulador tiene varias limitaciones que hacen que no sea seguro emplearlo como un entorno sandbox seguro en su forma actual.

Problemas de seguridad

Hay varias formas de "escapar" de la VM, como simplemente crear un nuevo proceso/hilo, programar llamadas a procedimientos asincrónicos (APC), etc.

Las llamadas al sistema relacionadas con la GUI de Windows también pueden realizar llamadas anidadas directamente al modo de usuario desde el kernel, lo que actualmente pasaría por alto la capa de hipervisor. Por este motivo, los ejecutables GUI como notepad.exe solo se virtualizan parcialmente cuando se ejecutan en WinVisor.

Para demostrar esto, WinVisor incluye un modificador de línea de comandos -nx en el emulador. Esto obliga a que toda la imagen EXE de destino se marque como no ejecutable en la memoria antes de iniciar la CPU virtual, lo que provoca que el proceso se bloquee si el proceso host intenta ejecutar cualquier código de forma nativa. Sin embargo, aún no es seguro confiar en esto: la aplicación de destino podría hacer que la región vuelva a ser ejecutable o simplemente asignar memoria ejecutable en otro lugar.

A medida que la DLL de WinVisor se inyecta en el proceso de destino, existe dentro del mismo espacio de dirección virtual que el ejecutable de destino. Esto significa que el código que se ejecuta bajo la CPU virtual puede acceder directamente a la memoria dentro del módulo de hipervisor del host, lo que potencialmente podría dañarlo.

Memoria de invitado no ejecutable

Si bien la CPU virtual está configurada para soportar NX, todas las regiones de memoria están actualmente reflejadas en el invitado con acceso completo a RWX.

Solo un hilo

Actualmente, el emulador solo admite la virtualización de un único hilo. Si el ejecutable de destino crea subprocesos adicionales, se ejecutarán de forma nativa. Para soportar múltiples subprocesos, se podría desarrollar un pseudoprogramador para manejar esto en el futuro.

El cargador paralelo de Windows está deshabilitado para garantizar que todas las dependencias del módulo se carguen mediante un solo hilo.

Excepciones de software

Actualmente no se admiten excepciones de software virtualizado. Si ocurre una excepción, el sistema llamará a la función KiUserExceptionDispatcher de forma nativa como de costumbre.

Conclusión

Como se ve arriba, el emulador funciona bien con una amplia gama de ejecutables en su forma actual. Si bien actualmente es eficaz para registrar llamadas al sistema e interrupciones, se requerirá mucho trabajo más para que su uso sea seguro para fines de análisis de malware. A pesar de ello, el proyecto proporciona un marco eficaz para el desarrollo futuro.

Enlaces del proyecto

https://github.com/x86matthew/WinVisor

Puedes encontrar al autor en X en @x86matthew.