Cyril François

NightMARE en la calle 0xelm, una visita guiada

En este artículo, se describe nightMARE, una biblioteca basada en Python para investigadores de malware desarrollada por Elastic Security Labs para ayudar a escalar el análisis. Describe cómo usamos nightMARE para desarrollar extractores de configuración de malware y crear indicadores de inteligencia.

17 min de lecturaAnálisis de malware
NightMARE en la calle 0xelm, una visita guiada

Introducción

Desde la creación de Elastic Security Labs, nos centramos en el desarrollo de herramientas de análisis de malware no solo para ayudar en nuestra investigación y análisis, sino también para lanzarlas al público. Queremos retribuir a la comunidad y retribuir todo lo que obtengamos de ella. En un esfuerzo por hacer que estas herramientas sean más robustas y reducir la duplicación de código, creamos la biblioteca de Python nightMARE. Esta biblioteca reúne varias características útiles para la ingeniería inversa y el análisis de malware. Lo usamos principalmente para crear nuestros extractores de configuración para diferentes familias de malware generalizadas, pero nightMARE es una biblioteca que se puede aplicar a múltiples casos de uso.

Con el lanzamiento de la versión 0.16, queremos presentar oficialmente la biblioteca y proporcionar detalles en este artículo sobre algunas características interesantes que ofrece este módulo, así como un breve tutorial que explica cómo usarlo para implementar su propio extractor de configuración compatible con la última versión de LUMMA (a partir de la fecha de publicación).

recorrido por las características de nightMARE

Desarrollado por Rizin

Para reproducir las capacidades de los desensambladores populares, nightMARE empleó inicialmente un conjunto de módulos de Python para realizar las diversas tareas necesarias para el análisis estático. Por ejemplo, usamos LIEF para el análisis de ejecutables (PE, ELF), Capstone para desensamblar binarios y SMDA para obtener análisis de referencias cruzadas (refx).

Estas numerosas dependencias hicieron que el mantenimiento de la biblioteca fuera más complejo de lo necesario. Por eso, para reducir al máximo el uso de módulos de terceros, decidimos emplear el marco de ingeniería inversa más completo disponible. Nuestra elección gravitó naturalmente hacia Rizin.

Rizin es un software de ingeniería inversa de código abierto, bifurcado del proyecto Radare2. Su velocidad, diseño modular y un conjunto casi infinito de funciones basadas en sus comandos similares a Vim lo convierten en una excelente opción de backend. Lo integramos en el proyecto empleando el módulo rz-pipe , lo que hace que sea muy fácil crear e instrumentar una instancia de Rizin desde Python.

Estructura del proyecto

El proyecto se estructura en torno a tres ejes:

  • El módulo "análisis" contiene submódulos útiles para el análisis estático.
  • El módulo "core" contiene submódulos comúnmente útiles: operaciones bit a bit, conversión de enteros y expresiones regulares recurrentes para la extracción de configuraciones.
  • El módulo "malware" contiene todas las implementaciones de algoritmos (criptografía, desempaquetado, extracción de configuraciones, etc.), agrupadas por familia de malware y, cuando corresponda, por versión.

Módulos de análisis

Para el análisis binario estático, este módulo ofrece dos técnicas de trabajo complementarias: análisis de desmontaje e instrucciones con Rizin a través del módulo de inversión y emulación de instrucciones a través del módulo de emulación.

Por ejemplo, cuando las constantes se mueven manualmente a la pila, en lugar de intentar analizar las instrucciones una por una para recuperar las inmediatas, es posible emular todo el fragmento de código y leer los datos en la pila una vez que se realiza el procesamiento.

Otro ejemplo que veremos más adelante en este artículo es que, en el caso de las funciones criptográficas, si es complejo, a menudo es más sencillo llamarlo directamente en el binario mediante emulación que intentar implementarlo manualmente.

Módulo de inversión

Este módulo contiene la clase Rizin, que es una abstracción de las funcionalidades de Rizin que envía comandos directamente a Rizin gracias a rz-pipe y ofrece al usuario una asombrosa cantidad de poder de análisis gratis. Debido a que es una abstracción, las funciones que expone la clase se pueden usar fácilmente en un script sin conocimiento previo del marco.

Aunque esta clase expone muchas características diferentes, no estamos tratando de ser exhaustivos. El objetivo es reducir el código duplicado para funcionalidades recurrentes en todas nuestras herramientas. Sin embargo, si un usuario encuentra que falta una función, puede interactuar directamente con el objeto rz-pipe para enviar comandos a Rizin y lograr sus objetivos.

Aquí hay una breve lista de las funciones que más usamos:

# Disassembling
def disassemble(self, offset: int, size: int) -> list[dict[str, typing.Any]]
def disassemble_previous_instruction(self, offset: int) -> dict[str, typing.Any]
def disassemble_next_instruction(self, offset: int) -> dict[str, typing.Any]

# Pattern matching
def find_pattern(
    self, 
    pattern: str,
    pattern_type: Rizin.PatternType) -> list[dict[str, typing.Any]]
def find_first_pattern(
    self,
    patterns: list[str],
    pattern_type: Rizin.PatternType) -> int

# Reading bytes
def get_data(self, offset: int, size: int | None = None) -> bytes
def get_string(self, offset: int) -> bytes

# Reading words
def get_u8(self, offset: int) -> int
...
def get_u64(self, offset: int) -> int

# All strings, functions
def get_strings(self) -> list[dict[str, typing.Any]]
def get_functions(self) -> list[dict[str, typing.Any]]

# Xrefs
def get_xrefs_from(self, offset: int) -> list
def get_xrefs_to(self, offset: int) -> list[int]

Módulo de emulación

En la versión 0.16, rediseñamos el módulo de emulación para aprovechar al máximo las capacidades de Rizin para realizar sus diversas tareas relacionadas con los datos. Bajo el capó, está empleando el motor Unicorn para realizar la emulación.

Por ahora, este módulo solo ofrece una emulación de PE "ligera" con la clase WindowsEmulator, ligera en el sentido de que solo se hace el mínimo estricto para cargar un PE. Sin reubicaciones, sin archivos DLL, sin emulación del sistema operativo. El objetivo no es emular completamente un ejecutable de Windows como Qiling o Sogen, sino ofrecer una forma sencilla de ejecutar fragmentos de código o secuencias cortas de funciones conociendo sus limitaciones.

La clase WindowsEmulator ofrece varias abstracciones útiles.

# Load PE and its stack
def load_pe(self, pe: bytes, stack_size: int) -> None

# Manipulate stack
def push(self, x: int) -> None
def pop(self) -> int

# Simple memory management mechanisms
def allocate_memory(self, size: int) -> int
def free_memory(self, address: int, size: int) -> None

# Direct ip and sp manipulation
@property
def ip(self) -> int
@property
def sp(self) -> int

# Emulate call and ret
def do_call(self, address: int, return_address: int) -> None
def do_return(self, cleaning_size: int = 0) -> None

# Direct unicorn access
@property
def unicorn(self) -> unicorn.Uc

La clase permite el registro de dos tipos de ganchos: ganchos de unicornio normales y ganchos IAT.

# Set unicorn hooks, however the WindowsEmulator instance get passed to the callback instead of unicorn
def set_hook(self, hook_type: int, hook: typing.Callable) -> int:

# Set hook on import call
def enable_iat_hooking(self) -> None:
def set_iat_hook(
        self,
        function_name: bytes,
        hook: typing.Callable[[WindowsEmulator, tuple, dict[str, typing.Any]], None],
) -> None:

Como ejemplo de uso, usamos el DismHost.exe binario de Windows .

El binario emplea la importación Sleep en la dirección 0x140006404:

Por lo tanto, crearemos un script que registre un gancho IAT para la importación de Sleep, inicie la ejecución de la emulación en la dirección 0x140006404y termine en la dirección 0x140006412.

# coding: utf-8

import pathlib

from nightMARE.analysis import emulation


def sleep_hook(emu: emulation.WindowsEmulator, *args) -> None:
    print(
        "Sleep({} ms)".format(
            emu.unicorn.reg_read(emulation.unicorn.x86_const.UC_X86_REG_RCX)
        ),
    )
    emu.do_return()


def main() -> None:
    path = pathlib.Path(r"C:\Windows\System32\Dism\DismHost.exe")
    emu = emulation.WindowsEmulator(False)
    emu.load_pe(path.read_bytes(), 0x10000)
    emu.enable_iat_hooking()
    emu.set_iat_hook("KERNEL32.dll!Sleep", sleep_hook)
    emu.unicorn.emu_start(0x140006404, 0x140006412)


if __name__ == "__main__":
    main()

Es importante tener en cuenta que la función de gancho debe regresar necesariamente con la función do_return para que podamos llegar a la dirección ubicada luego de la llamada.

Cuando se inicia el emulador, nuestro gancho se ejecuta correctamente.

Módulo de malware

El módulo de malware contiene todas las implementaciones de algoritmos para cada familia de malware que cubrimos. Estos algoritmos pueden cubrir la extracción de configuraciones, las funciones criptográficas o el desempaquetado de muestras, según el tipo de malware. Todos estos algoritmos emplean las funcionalidades del módulo de análisis para hacer su trabajo y proporcionan buenos ejemplos de cómo usar la biblioteca.

Con el lanzamiento de la versión 0.16, estas son las diferentes familias de malware que cubrimos.

blister
deprecated
ghostpulse
latrodectus
lobshot
lumma
netwire
redlinestealer
remcos
smokeloader
stealc
strelastealer
xorddos

La implementación completa de los algoritmos LUMMA que cubrimos en el tutorial del siguiente capítulo se puede encontrar en el submódulo LUMMA.

Tenga en cuenta que la naturaleza de rápido desarrollo del malware dificulta el mantenimiento de estos módulos, pero agradecemos cualquier ayuda al proyecto, contribución directa o problemas de apertura.

Ejemplo: Extracción de configuración de LUMMA

LUMMA STEALER, también conocido como LUMMAC2, es un malware de robo de información que todavía se usa ampliamente en campañas de infección a pesar de una reciente operación de eliminación en mayo de 2025. Este malware incorpora ofuscación de flujo de control y cifrado de datos, lo que hace que sea más difícil de analizar tanto estática como dinámicamente.

En esta sección, usaremos el siguiente ejemplo sin cifrar como referencia: 26803ff0e079e43c413e10d9a62d344504a134d20ad37af9fd3eaf5c54848122

Hacemos un breve análisis de cómo descifra sus nombres de dominio paso a paso, y luego demostramos a lo largo del camino cómo construimos el extractor de configuración usando nightMARE.

Paso 1: Inicializar el contexto ChaCha20

En esta versión, LUMMA realiza la inicialización de su contexto criptográfico luego de cargar WinHTTP.dll, con la clave de descifrado y nonce; Este contexto se reutilizará para cada llamada a la función de descifrado ChaCha20 sin reinicializar. El matiz aquí es que un contador interno dentro del contexto se actualiza con cada uso, por lo que más adelante tendremos que tener en cuenta el valor de este contador antes del primer descifrado del dominio y luego descifrarlos en el orden correcto.


Para reproducir este paso en nuestro script, necesitamos recopilar la clave y el nonce. El problema es que no conocemos su ubicación de antemano, pero sabemos dónde se emplean. Hacemos coincidir el patrón de esta parte del código, luego extraemos las direcciones g_key_0 (key) y g_key_1 (nonce) de las instrucciones.

CRYPTO_SETUP_PATTERN = "b838?24400b???????00b???0???0096f3a5"

def get_decryption_key_and_nonce(binary: bytes) -> tuple[bytes, bytes]:
    # Load the binary in Rizin
    rz = reversing.Rizin.load(binary)

    # Find the virtual address of the pattern
    if not (
        x := rz.find_pattern(
            CRYPTO_SETUP_PATTERN, reversing.Rizin.PatternType.HEX_PATTERN
        )
    ):
        raise RuntimeError("Failed to find crypto setup pattern virtual address")

    # Extract the key and nonce address from the instruction second operand
    crypto_setup_va = x[0]["address"]
    key_and_nonce_address = rz.disassemble(crypto_setup_va, 1)[0]["opex"]["operands"][
        1
    ]["value"]

    # Return the key and nonce data
    return rz.get_data(key_and_nonce_address, CHACHA20_KEY_SIZE), rz.get_data(
        key_and_nonce_address + CHACHA20_KEY_SIZE, CHACHA20_NONCE_SIZE
    )

def build_crypto_context(key: bytes, nonce: bytes, initial_counter: int) -> bytes:
    crypto_context = bytearray(0x40)
    crypto_context[0x10:0x30] = key
    crypto_context[0x30] = initial_counter
    crypto_context[0x38:0x40] = nonce
    return bytes(crypto_context)

Paso 2: Localiza la función de descifrado

En esta versión, la función de descifrado de LUMA se ubica fácilmente en las muestras, ya que se emplea inmediatamente luego de cargar las importaciones de WinHTTP.

Derivamos el patrón hexadecimal de los primeros bytes de la función para ubicarlo en nuestro script:

DECRYPTION_FUNCTION_PATTERN = "5553575681ec1?0100008b??243?01000085??0f84??080000"

def get_decryption_function_address(binary) -> int:
    # A cache system exist so the binary is only loaded once, then we get the same instance of Rizin :)
    if x := reversing.Rizin.load(binary: bytes).find_pattern(
        DECRYPTION_FUNCTION_PATTERN, reversing.Rizin.PatternType.HEX_PATTERN
    ):
        return x[0]["address"]
    raise RuntimeError("Failed to find decryption function address")

Paso 3: Localiza la dirección base del dominio cifrado

Mediante el uso de referencias externas de la función de descifrado, que no se llama con indirección ofuscada como otras funciones de LUMMA, podemos encontrar fácilmente dónde se llama para descifrar los dominios.

Al igual que con el primer paso, usaremos las instrucciones para descubrir la dirección base de los dominios cifrados en el binario:

C2_LIST_MAX_LENGTH = 0xFF
C2_SIZE = 0x80
C2_DECRYPTION_BRANCH_PATTERN = "8d8?e0?244008d7424??ff3?565?68????4500e8????ffff"

def get_encrypted_c2_list(binary: bytes) -> list[bytes]:
    rz = reversing.Rizin.load(binary)
    address = get_encrypted_c2_list_address(binary)
    encrypted_c2 = []
    for ea in range(address, address + (C2_LIST_MAX_LENGTH * C2_SIZE), C2_SIZE):
        encrypted_c2.append(rz.get_data(ea, C2_SIZE))
    return encrypted_c2


def get_encrypted_c2_list_address(binary: bytes) -> int:
    rz = reversing.Rizin.load(binary)
    if not len(
        x := rz.find_pattern(
            C2_DECRYPTION_BRANCH_PATTERN, reversing.Rizin.PatternType.HEX_PATTERN
        )
    ):
        raise RuntimeError("Failed to find c2 decryption pattern")

    c2_decryption_va = x[0]["address"]
    return rz.disassemble(c2_decryption_va, 1)[0]["opex"]["operands"][1]["disp"]

Paso 4: Descifrar dominios mediante emulación

Un análisis rápido de la función de descifrado muestra que esta versión de LUMMA emplea una versión ligeramente personalizada de ChaCha20. Reconocemos las mismas funciones de descifrado pequeñas y diversas dispersas en los binarios. Aquí, se emplean para descifrar partes de la constante ChaCha20 "expandir k de 32 bytes", que luego se derivan de XOR-ROL antes de almacenar en la estructura de contexto.

Si bien podríamos implementar la función de descifrado en nuestro script, tenemos todas las direcciones necesarias para demostrar cómo podemos llamar directamente a la función ya presente en el binario para descifrar nuestros dominios, empleando el módulo de emulación de nightMARE.

# We need the right initial value, before decrypting the domain
# the function is already called once so 0 -> 2
CHACHA20_INITIAL_COUNTER = 2

def decrypt_c2_list(
    binary: bytes, encrypted_c2_list: list[bytes], key: bytes, nonce: bytes
) -> list[bytes]:
    # Get the decryption function address (step 2)
    decryption_function_address = get_decryption_function_address(binary)

    # Load the emulator, True = 32bits
    emu = emulation.WindowsEmulator(True)
 
    # Load the PE in the emulator with a stack of 0x10000 bytes
    emu.load_pe(binary, 0x10000)
    
    # Allocate the chacha context
    chacha_ctx_address = emu.allocate_memory(CHACHA20_CTX_SIZE)
    
    # Write at the chacha context address the crypto context
    emu.unicorn.mem_write(
        chacha_ctx_address,
        build_crypto_context(
            key,
            nonce,
            CHACHA20_INITIAL_COUNTER, 
        ),
    )

    decrypted_c2_list = []
    for encrypted_c2 in encrypted_c2_list:
	 # Allocate buffers
        encrypted_buffer_address = emu.allocate_memory(C2_SIZE)
        decrypted_buffer_address = emu.allocate_memory(C2_SIZE)
        
        # Write encrypted c2 to buffer
        emu.unicorn.mem_write(encrypted_buffer_address, encrypted_c2)

        # Push arguments
        emu.push(C2_SIZE)
        emu.push(decrypted_buffer_address)
        emu.push(encrypted_buffer_address)
        emu.push(chacha_ctx_address)
 
        # Emulate a call
        emu.do_call(decryption_function_address, emu.image_base)

        # Fire!
        emu.unicorn.emu_start(decryption_function_address, emu.image_base)

        # Read result from decrypted buffer
        decrypted_c2 = bytes(
            emu.unicorn.mem_read(decrypted_buffer_address, C2_SIZE)
        ).split(b"\x00")[0]

        # If result isn't printable we stop, no more domain
        if not bytes_re.PRINTABLE_STRING_REGEX.match(decrypted_c2):
            break

        # Add result to the list
        decrypted_c2_list.append(b"https://" + decrypted_c2)

        # Clean up the args
        emu.pop()
        emu.pop()
        emu.pop()
        emu.pop()

        # Free buffers
        emu.free_memory(encrypted_buffer_address, C2_SIZE)
        emu.free_memory(decrypted_buffer_address, C2_SIZE)

       # Repeat for the next one ...

    return decrypted_c2_list

Resultado

Finalmente, podemos ejecutar nuestro módulo con pytest y ver la lista LUMMA C2 (decrypted_c2_list):

https://mocadia[.]com/iuew  
https://mastwin[.]in/qsaz  
https://ordinarniyvrach[.]ru/xiur  
https://yamakrug[.]ru/lzka  
https://vishneviyjazz[.]ru/neco  
https://yrokistorii[.]ru/uqya  
https://stolevnica[.]ru/xjuf  
https://visokiykaf[.]ru/mntn  
https://kletkamozga[.]ru/iwqq

Este ejemplo destaca cómo se puede usar la biblioteca nightMARE para el análisis binario, específicamente, para extraer la configuración del ladrón LUMMA.

Descargar nightMARE

La implementación completa del código presentado en este artículo está disponible aquí.

Conclusión

nightMARE es un módulo de Python versátil, basado en las mejores herramientas que la comunidad de código abierto tiene para ofrecer. Con el lanzamiento de la versión 0.16 y este breve artículo, esperamos demostrar sus capacidades y potencial.

Internamente, el proyecto está en el corazón de varios proyectos aún más ambiciosos, y continuaremos manteniendo nightMARE lo mejor que podamos.