Malware Development

Memoria virtual

El subsistema de gestión de memoria de Windows no expone la memoria física (RAM) directamente a los procesos. En su lugar, el sistema operativo implementa una abstracción basada en un Espacio de Direccionamiento Virtual Lineal (Virtual Address Space). Este diseño aísla a cada proceso, proporcionando la condición de que posee un espacio de direccionamiento privado y exclusivo.

Arquitectura del Espacio del Direccionamiento Virtual

La traslación de direcciones virtuales a direcciones físicas se realiza en tiempo de ejecución mediante la Unidad de Gestión de Memoria (MMU) del procesador en coordinación con el Gestor de Memoria del kernel.

Aislamiento de memoria y paginación

El espacio de direcciones virtuales se divide en bloques de tamaño fijo denominados Páginas, cuyo tamaño estándar en arquitecturas x86 y x64 es de 4 KB. La memoria física también se fractura en bloques idénticos llamados Page Frames.

  • Mapeo no contiguo: Dos páginas consecutivas en el espacio virtual de un proceso no requieren estar adyacentes en la memoria RAM física.
  • Compartición de memoria física: Diferentes procesos pueden mapear la misma celda de memoria física en direcciones virtuales totalmente distintas dentro de sus respectivos contextos (ej. al cargar librerías compartidas como ntdll.dll).
  • Intercambio a disco (Paging): Cuando la demanda de memoria física supera la capacidad del hardware, el Gestor de Memoria transfiere páginas inactivas hacia el almacenamiento secundario (archivo de paginación o pagefile.sys). Si un hilo intenta acceder a una dirección virtual cuya página ha sido trasladada a disco, la MMU genera una interrupción por fallo de página (el famoso error de Page Fault), obligando al kernel a cargar los datos de vuelta en la RAM.

Límites de arquitectura (x86 frente a x64)

La capacidad de direccionamiento virtual varía drásticamente según la plataforma de hardware:

  • Sistemas de 32 bits (x86): Poseen un límite teórico de 4 GB de memoria virtual (0xFFFFFFFF). Por defecto, Windows divide este espacio simétricamente:

    • Lower half (reservado para el modo usuario): Rango 0x00000000 - 0x7FFFFFFF (2 GB) asignados al almacenamiento privado del proceso.
    • Upper half (reservado para el modo kernel): Rango 0x80000000 - 0xFFFFFFFF (2 GB) reservados para las estructuras protegidas del sistema operativo.
  • Sistemas de 64 bits (x64): El límite arquitectónico se extiende significativamente. En las compilaciones modernas de Windows, se asigna un espacio simétrico de 128 TB para el modo usuario y 128 TB para el modo kernel, existiendo una región masiva intermedia denominada "espacio no mapeado" (Unmapped region).

Estados de las páginas de memoria

El ciclo de vida de una página de memoria virtual dentro del espacio de direccionamiento de un proceso se rige por tres estados específicos demostrados a continuación mediante el siguiente diagrama de flujo:

Ciclo de vida de la memoria
Ciclo de vida de la memoria virtual

  • Free (libre): La página no está reservada ni asignada. El proceso no tiene derechos de acceso. Cualquier intento de lectura, escritura o ejecución sobre una dirección en este estado genera una excepción de violación de acceso inmediato (STATUS_ACCESS_VIOLATION).

  • Reserved (reservada): El proceso asegura un rango continuo de direcciones virtuales para evitar que otras funciones de asignación lo utilicen. En este estado, no existe almacenamiento físico asociado (ni en RAM ni en disco). La página sigue siendo inaccesible para el hilo hasta que cambie de estado.

  • Committed (asignada): El Gestor de Memoria reserva la cuota correspondiente en la memoria RAM o en el archivo de paginación. La página se vuelve accesible y su comportamiento queda determinado por las constantes de protección de memoria.

Protección de memoria

Una vez que una página transiciona al estado Committed, es necesario asignar los permisos específicos de acceso. Las violaciones de estas reglas activarán ciertas protecciones basadas en hardware.

Tipos de protecciones

  • PAGE_NOACCESS: Deshabilita cualquier tipo de operación (lectura, escritura o ejecución). Su uso legítimo se centra en páginas de guarda (Guard Pages) para detectar desbordamientos de pila (el famoso stack overflow).
  • PAGE_READONLY: Permite exclusivamente operaciones de lectura. Si un hilo intenta escribir datos en este rango, el procesador aborta la instrucción y genera un fallo. Se emplea en las secciones de datos estáticos de los ejecutables (ej. .rdata).
  • PAGE_READWRITE: Habilita la lectura y la modificación de datos. Es el estado estándar de las regiones de memoria dinámica orientadas a datos como el Heap (Montón) y el Stack (Pila).
  • PAGE_EXECUTE_READ: Permite leer y ejecutar código binario. Es la protección asignada por defecto a las secciones de código ejecutable de las DLLs y binarios (.text).
  • PAGE_EXECUTE_READWRITE (RWX): Otorga permisos totales (Lectura, Escritura y Ejecución). Es una constante poco común en software legítimo, restringida casi exclusivamente a motores de compilación en tiempo real (JIT Compilers) como los procesadores de JavaScript en navegadores web. Es un índice de compromiso en la actualidad.

Mitigaciones de seguridad y enfoque Red Team

El control de los estados y protecciones de memoria constituye el pilar fundamental del desarrollo de malware y las técnicas de evasión, debido a las mitigaciones integradas en el núcleo de Windows y otras soluciones de seguridad.

Data Execution Prevention (DEP)

DEP es una tecnología de protección a nivel de sistema que trabaja en conjunción con el bit NX (No-Execute) o XD (Execute-Disable) de la CPU. Su función es garantizar que las páginas de memoria que no han sido explícitamente marcadas con atributos de ejecución (como PAGE_EXECUTE_READ) no puedan ejecutar código.

Si un atacante inyecta código malicioso (shellcode) en un buffer del montón o de la pila protegido con PAGE_READWRITE, e intenta desviar el puntero de instrucciones (RIP/EIP) hacia esa dirección, la CPU identificará el flag de hardware y denegará la ejecución, finalizando el proceso de forma inmediata.

A tener en cuenta

Las soluciones de seguridad actuales como EDRs, XDR y AV monitorizan de forma estricta las llamadas a las APIs de asignación de memoria.

  • Indicadores de Compromiso (IoC): La asignación de permisos de memoria con la constante PAGE_EXECUTE_READWRITE es considerado un índice de compromiso crítico por los motores de detección heurísticos.

  • Metodología de asignación de memoria Para evadir firmas dinámicas, el software ofensivo no asigna directamente memoria ejecutable. En su lugar, aplica una transición de estados:

    1. Se reserva y asigna memoria utilizando únicamente permisos de escritura (PAGE_READWRITE).
    2. Se escribe el shellcode en el buffer.
    3. Se invoca a la API VirtualProtect (o su equivalente NTAPI NtProtectVirtualMemory) para quitar el permiso de escritura y asignar el atributo de ejecución PAGE_EXECUTE_READ.
    4. Se inicializa el hilo para la ejecución del código.

Los dos elementos mencionados anteriormente presentan una alta probabilidad de que el binario resultante sea detectado incluso por los motores más básicos de seguridad. Se explicará en futuros post que protecciones y medidas aplicar a nuestro código para evitar en gran medida este tipo de detecciones.

Ejemplo: Asignación de memoria en Windows (C)

Windows nos proporciona diferentes capas de abstracción para la reserva de buffers. Es necesario conocer el nivel en el que opera cada función a la hora de desarrollar nuestro malware.

A continuación, se muestra diferentes métodos de asignación de memoria:

// Allocation en el Heap (Espacio de Usuario - Gestión de Librería de C)
PVOID pAddress1 = malloc(100);

// Allocation en el Heap Nativo del Proceso (Subsistema Win32)
PVOID pAddress2 = HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, 100);

// Allocation de Memoria Virtual Directa (Interacción con el Kernel)
PVOID pAddress3 = VirtualAlloc(NULL, 100, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);

Handles y objetos

Cada proceso tiene una tabla de handles, y cada handle apunta a objetos del kernel.

Un objeto son instancias en ejecución de estructuras estáticas como procesos, eventos, archivos incluso hilos. Los objetos residen en el lado del kernel, y no se pueden acceder mediante código en el lado del usuario.

Si quisiéramos acceder a un objeto desde de el lado del usuario, solo obtendríamos el handle al objeto. Esto evita que nuestro código acceda directamente al objeto.

Handles a procesos e hilos

Cuando el subsistema de ejecución de Windows crea una nueva instancia de un programa, el kernel le asigna un número entero único denominado PID. Este valor se utiliza para distinguir un proceso en ejecución del resto de procesos activos en el sistema (un mismo PID puede aparecer durante diferentes instancias a lo largo del tiempo)

Este mismo modelo se aplica a los hilos. Cuando se crea un hilo, el sistema le asigna un TID (thread ID). Es necesario destacar que el TID es único a nivel global en todo el sistema operativo, no solo de forma relativa dentro del proceso que lo contiene.

El subsistema Win32 proporciona interfaces documentadas para solicitar handles válidos a partir de los identificadores del sistema.

OpenProcess

Abre un handle hacia un objeto de proceso existente evaluando su PID y la máscara de acceso.

HANDLE OpenProcess(
[in] DWORD dwDesiredAccess,  // Privilegios solicitados (ej. PROCESS_ALL_ACCESS)
[in] BOOL  bInheritHandle,   // Determina si los procesos hijos heredarán este handle
[in] DWORD dwProcessId       // El PID del proceso objetivo
);

OpenThread

Ejecuta la misma operación a nivel de hilo, solicitando un descriptor de acceso mediante su TID.

HANDLE OpenThread(
[in] DWORD dwDesiredAccess,  // Privilegios solicitados (ej. THREAD_SUSPEND_RESUME)
[in] BOOL  bInheritHandle,
[in] DWORD dwThreadId        // El TID del hilo objetivo
);

CloseHandle

Cada vez que se abre un handle mediante APIs como OpenProcess o CreateFileW, el kernel incrementa un contador interno de referencias (Reference Count) sobre ese objeto. El objeto no se destruirá por completo de la memoria del sistema mientras su contador de referencias sea superior a cero.

Cuando el uso de un handle concluye, estamos obligados a destruirlo invocando la API CloseHandle:

BOOL CloseHandle(
[in] HANDLE hObject   // El descriptor de acceso que se desea cerrar
);

Conclusión

Al utilizar OpenProcess u OpenThread, el parámetro dwDesiredAccess es un vector de detección común para las soluciones de seguridad.

Solicitar privilegios como PROCESS_ALL_ACCESS o THREAD_ALL_ACCESS sobre procesos críticos (como lsass.exe) producirá alertas de telemetría. El desarrollo de malware moderno exige aplicar el principio de mínimo privilegio, solicitando estrictamente los flags necesarios (por ejemplo, únicamente PROCESS_VM_WRITE | PROCESS_VM_OPERATION para inyección de memoria).